Skip to content

archive: harden tar extraction against path traversal - #45

Merged
thaJeztah merged 8 commits into
moby:mainfrom
ctalledo:go-archive-tar-hardening
Jul 24, 2026
Merged

archive: harden tar extraction against path traversal#45
thaJeztah merged 8 commits into
moby:mainfrom
ctalledo:go-archive-tar-hardening

Conversation

@ctalledo

@ctalledo ctalledo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Combines the tar path-traversal hardening previously split across #24, #25 and #26 into a single PR, rebased onto current main. Addresses ART-224 and the externally-reported tar-extraction breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

What it does

  • Reject traversal entries (.., absolute) instead of clamping them, and bound all extraction with os.Root (openat-based) for kernel-enforced containment.
  • Create symlinks with root.Symlink (target stored verbatim, so absolute targets are preserved) and hardlinks with root.Link + a filepath.IsLocal check.
  • Resolve symlink components with fsRootPath, a straight fork of containerd/continuity fs.RootPath (kept verbatim, with its test, for easy upstream sync).
  • Treat tar names as POSIX and convert to native (filepath.FromSlash) at each filesystem boundary; skip entries whose name/target Windows cannot represent.
  • dirCache — reuse the parent-directory fd across consecutive entries (*at(2) syscalls) to amortize os.Root's per-call path re-evaluation.

Includes regression tests that fail on the previous code. Supersedes #24, #25 and #26. Windows-specific test coverage needs Windows CI and is a follow-up.

cc @thaJeztah @vvoland @tonistiigi

@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 51.36986% with 142 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.19%. Comparing base (216738e) to head (47e37dd).
⚠️ Report is 23 commits behind head on main.

Files with missing lines Patch % Lines
archive.go 60.57% 19 Missing and 22 partials ⚠️
rootpath.go 39.65% 31 Missing and 4 partials ⚠️
diff.go 48.88% 14 Missing and 9 partials ⚠️
archive_unix.go 58.97% 11 Missing and 5 partials ⚠️
archive_linux.go 35.71% 5 Missing and 4 partials ⚠️
time_nonwindows.go 50.00% 5 Missing and 3 partials ⚠️
dev_darwin.go 0.00% 7 Missing ⚠️
dev_unix.go 66.66% 1 Missing and 1 partial ⚠️
archive_windows.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #45      +/-   ##
==========================================
- Coverage   65.81%   64.19%   -1.63%     
==========================================
  Files          42       44       +2     
  Lines        2039     2223     +184     
==========================================
+ Hits         1342     1427      +85     
- Misses        519      594      +75     
- Partials      178      202      +24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread safepath.go Outdated
Comment thread safepath.go Outdated
Comment thread diff.go Outdated
Comment thread diff.go Outdated
Comment thread archive.go Outdated
Comment thread archive.go Outdated
Comment thread archive.go Outdated
Comment thread archive.go Outdated
Comment thread diff.go Outdated
Comment thread diff.go Outdated
@thaJeztah

This comment was marked as outdated.

@ctalledo
ctalledo force-pushed the go-archive-tar-hardening branch from 979434e to fc27b1b Compare July 15, 2026 23:15
Comment thread archive.go Outdated
Comment thread archive.go Outdated
@ctalledo

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @thaJeztah — I've reworked the PR to the native-at-boundary model you laid out (and squashed it to a single commit, plus a separate tests commit):

  • safeResolvefsRootPath: replaced with a straight verbatim fork of containerd/continuity fs/path.go (RootPath un-exported to fsRootPath, plus walkLink/walkLinks), and copied path_test.go (as rootpath_test.go, minus TestDirectoryCompare). This adds a new test dependency on github.com/containerd/continuity v0.5.0 — flagging in case you'd prefer to handle the dep differently.
  • POSIX → native at the boundary: tar names/linknames are converted with filepath.FromSlash before every os.Root / fsRootPath call; createTarFile's dstPath is now always native; POSIX (hdr.Name) is kept only for logical checks (prefix matches, map keys, error messages). Covers your archive.go comments (createTarFile / createImpliedDirectories / Link / Symlink / Lstat / RemoveAll) and diff.go.
  • Windows-unrepresentable names: kept a small skip for entries whose name or hardlink target contains : or \ (since FromSlash doesn't rewrite a literal \). Happy to drop or change per your take.
  • os.RemoveAll in the opaque-whiteout walk (diff.go): good question — left as-is for now (it walks resolved host paths from fsRootPath); can switch to a root-relative root.RemoveAll as a follow-up if you prefer.
  • lchown error wording: left for the follow-up you mentioned.

Also added regression tests that fail on the pre-fix code and pass here (parent-.. traversal, and a hardlink to a prefix-sharing sibling). Windows-specific tests need Windows CI — follow-up.

Comment thread rootpath_test.go Outdated
@thaJeztah

Copy link
Copy Markdown
Member

@ctalledo can you fix the linting failure so that CI can run? (and perhaps the small nit I left if you're updating anyway)

ctalledo added a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
- unrepresentableOnWindows now returns an error naming which value (entry
  name or hardlink target) is unrepresentable, so the skip log is
  accurate instead of always printing hdr.Name.
- Use strings.SplitSeq (go1.25) in createImpliedDirectories to avoid the
  intermediate slice allocation.
- makeRootPathTest: drop the unused *testing.T parameter to stay close to
  the containerd/continuity upstream.

Addresses review feedback from thaJeztah on moby#45.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
@ctalledo

Copy link
Copy Markdown
Contributor Author

Done in 7677303, @thaJeztah:

  • Lint failure — the unused *testing.T in the copied makeRootPathTest helper (rootpath_test.go) is now _.
  • NitsunrepresentableOnWindows now returns an error naming which value is unrepresentable (entry name vs. hardlink target) so the skip log is accurate, and createImpliedDirectories uses strings.SplitSeq.

CI is green on the updated head. Thanks for the review!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR consolidates prior work to harden tar extraction against path traversal by bounding extraction operations with os.Root (kernel-enforced containment), rejecting non-local entry names (absolute / ..), and improving runtime safety around symlink/hardlink handling. It also introduces a directory-FD cache to reduce repeated path-walk overhead during extraction and adds regression tests for previously exploitable breakout cases.

Changes:

  • Switch tar extraction (Unpack, UnpackLayer, createTarFile) to os.Root-bounded filesystem operations and reject non-local entry names.
  • Add fsRootPath (forked from containerd/continuity) plus tests to safely resolve paths through symlinks within a root.
  • Add dirCache to reuse the last parent directory FD (Unix) and update/extend regression tests for traversal and symlink breakout cases.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
utils_test.go Updates breakout test walk behavior to skip symlinks under the os.Root security model.
time_windows.go Removes legacy lchtimes helper (now handled via dirCache).
time_nonwindows.go Removes legacy lchtimes helper (now handled via dirCache).
rootpath.go Adds fsRootPath implementation for symlink-aware, root-bounded path resolution.
rootpath_test.go Adds non-Windows unit tests for fsRootPath.
go.mod Adds github.com/containerd/continuity dependency for tests/utilities.
go.sum Records new module checksums (continuity + indirect deps).
dircache_windows.go Introduces Windows dirCache implementation (delegates to os.Root).
dircache_unix.go Introduces Unix dirCache implementation using *at(2) syscalls for perf.
diff.go Moves UnpackLayer extraction to os.Root, integrates dirCache, updates whiteout handling.
chrootarchive/archive_unix_test.go Loosens assertion to accept either bounded-failure point while still verifying safety.
archive.go Core extraction hardening: os.Root, non-local rejection, new createTarFile signature, implied dir creation changes, Windows-name filtering.
archive_windows.go Updates handleLChmod signature to match new extraction flow (no-op on Windows).
archive_unix.go Updates handleLChmod to use dirCache/os.Root for bounded chmod.
archive_test.go Updates tests for the new createTarFile signature and adds new regression tests.
Comments suppressed due to low confidence (1)

utils_test.go:154

  • filepath.WalkDir callback calls info.IsDir() before checking err. When WalkDir reports an error, info can be nil, which can panic the test (nil dereference) instead of cleanly skipping the entry.
	return filepath.WalkDir(dest, func(path string, info os.DirEntry, err error) error {
		if info.IsDir() {
			if err != nil {
				// skip directory if error
				return filepath.SkipDir
			}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread archive.go Outdated
Comment thread diff.go
Comment thread archive.go Outdated
Comment thread go.mod Outdated
Comment thread archive.go Outdated
@thaJeztah
thaJeztah force-pushed the go-archive-tar-hardening branch from 272b3be to 86471f1 Compare July 16, 2026 08:33
thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
- unrepresentableOnWindows now returns an error naming which value (entry
  name or hardlink target) is unrepresentable, so the skip log is
  accurate instead of always printing hdr.Name.
- Use strings.SplitSeq (go1.25) in createImpliedDirectories to avoid the
  intermediate slice allocation.
- makeRootPathTest: drop the unused *testing.T parameter to stay close to
  the containerd/continuity upstream.

Addresses review feedback from thaJeztah on moby#45.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
@thaJeztah
thaJeztah force-pushed the go-archive-tar-hardening branch from 86471f1 to 4774927 Compare July 16, 2026 09:43
@thaJeztah
thaJeztah requested a review from Copilot July 16, 2026 09:43

This comment was marked as outdated.

Comment thread archive.go Outdated
// created within root via openat(2) semantics, without resolving to an
// absolute path; containment applies when the symlink is followed, not
// at creation.
if err := root.Symlink(filepath.FromSlash(hdr.Linkname), dstPath); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually looking at this one, and this may be wrong ... for symlinks, the target of the symlink is part of the symlink's content, so we need to preserve that verbatim, even if that doesn't match the platform's semantics.

So

root.Symlink("/usr/local/bin", "somewhere/in/root") should stay /usr/local/bin even if that doesn't match Windows.

It's a slightly tricky one perhaps in some situations, because that would produce either a broken symlink, or a symlink pointing outside the exported data, but it preserves the data as it was provided.

@thaJeztah

This comment was marked as resolved.

thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
- unrepresentableOnWindows now returns an error naming which value (entry
  name or hardlink target) is unrepresentable, so the skip log is
  accurate instead of always printing hdr.Name.
- Use strings.SplitSeq (go1.25) in createImpliedDirectories to avoid the
  intermediate slice allocation.
- makeRootPathTest: drop the unused *testing.T parameter to stay close to
  the containerd/continuity upstream.

Addresses review feedback from thaJeztah on moby#45.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
@thaJeztah
thaJeztah force-pushed the go-archive-tar-hardening branch from 816698c to 9849cef Compare July 16, 2026 11:00
thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
- unrepresentableOnWindows now returns an error naming which value (entry
  name or hardlink target) is unrepresentable, so the skip log is
  accurate instead of always printing hdr.Name.
- Use strings.SplitSeq (go1.25) in createImpliedDirectories to avoid the
  intermediate slice allocation.
- makeRootPathTest: drop the unused *testing.T parameter to stay close to
  the containerd/continuity upstream.

Addresses review feedback from thaJeztah on moby#45.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

Comment thread archive.go
Comment on lines +100 to +115
// isPathEscapes reports whether err is os.Root's path-containment error.
//
// os.Root currently returns an unexported errPathEscapes sentinel, so callers
// cannot detect it with errors.Is. Keep the string comparison isolated here
// until Go exports the error; see https://go.dev/issue/74640.
func isPathEscapes(err error) bool {
// https://github.com/golang/go/blob/go1.26.5/src/os/file.go#L421
const errPathEscapes = "path escapes from parent"
for err != nil {
if errors.Unwrap(err) == nil {
return err.Error() == errPathEscapes
}
err = errors.Unwrap(err)
}
return false
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sigh; good'Ol string-matching needed to match this;

Comment thread rootpath.go
path = newpath
if i == linksWalked {
newpath = filepath.Join("/", newpath)
newpath = filepath.Join(string(os.PathSeparator), newpath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened a PR in upstream to fix it there as well;

This comment was marked as off-topic.

This comment was marked as off-topic.

@vvoland

vvoland commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Does this LGTY?

vvoland@7f858a5

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thaJeztah

Copy link
Copy Markdown
Member

Rebased; without vvoland@7f858a5 - CI should fail, and will push that patch after.

@thaJeztah

Copy link
Copy Markdown
Member

Yup; fails; that's good

=== RUN   TestUnpackLayerCreatesImpliedDirectoriesThroughLowerLayerSymlink
    archive_unix_test.go:177: assertion failed: error is not nil: mkdir lib: not a directory
--- FAIL: TestUnpackLayerCreatesImpliedDirectoriesThroughLowerLayerSymlink (0.00s)

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

thaJeztah and others added 8 commits July 23, 2026 21:53
Co-authored-by: Cesar Talledo <cesar.talledo@docker.com>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Addresses ART-224 and the cluster of externally reported tar-extraction
breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

- Reject traversal entries instead of clamping them: normalize hdr.Name
  with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names
  via filepath.IsLocal, in both Unpack and UnpackLayer.
- Bound extraction with os.Root (openat-based); create symlinks with
  root.Symlink (target stored verbatim, so absolute targets are kept) and
  hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check.
- Cache the most recent parent directory fd (dirCache) so consecutive
  entries in the same directory use *at(2) syscalls, amortizing os.Root's
  per-call path re-evaluation.
- Resolve symlink components with fsRootPath, a straight fork of
  containerd/continuity fs.RootPath (path.go + path_test.go), un-exported
  and trimmed to the functions used, to ease upstream sync.
- tar header names are POSIX; convert to native paths with
  filepath.FromSlash at each os.Root / filesystem boundary, and skip
  entries whose name or hardlink target Windows cannot represent (":", "\").

archive: make lchtimes use os.Root for path resolution

Resolve the parent directory through os.Root and perform utimensat(2)
relative to the opened directory instead of using an absolute host path.

This preserves os.Root's path containment guarantees while still updating
the symlink itself using AT_SYMLINK_NOFOLLOW.

createImpliedDirectories: Keep implied dirs at ImpliedDirectoryMode under umask

createImpliedDirectories previously used user.MkdirAllAndChown, whose
setPermissions runs os.Chmod after creation, so implied parent
directories always ended up with ImpliedDirectoryMode regardless of the
process umask.

The os.Root rewrite creates them with root.Mkdir only, which applies the
mode subject to umask: under umask 0o027 an implied directory became
0o750 instead of 0o755.

Re-apply the mode with root.Chmod after each successful Mkdir so implied
directories keep ImpliedDirectoryMode independent of umask, matching the
prior behavior and the function's documented contract.

Co-authored-by: Cesar Talledo <cesar.talledo@docker.com>
Co-authored-by: Paweł Gronowski <git@grono.dev>
Co-authored-by: Sebastiaan van Stijn <github@gone.nl>
Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
Signed-off-by: Paweł Gronowski <git@grono.dev>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
os.Root.Chmod relies on chmodat(AT_SYMLINK_NOFOLLOW), which is not
supported on all Linux kernels and filesystems.

When that operation fails with ENOTSUP or EOPNOTSUPP, fall back to
chmod relative to the resolved parent directory. Symlink entries are
excluded beforehand, and hardlink entries are only chmod'd when their
target is not a symlink, preserving the existing no-follow semantics.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Some code-paths may return an error, in which case creating the parent
paths isn't needed. Move it later in the function to avoid this.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Open newly-created implied directories once and apply ownership and
permissions through the retained handle instead of separate os.Root
operations.

This avoids repeated doInRoot path resolution for Lchown/Chmod while
continuing to apply metadata only to newly-created directories.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Pass the extraction root to the overlay whiteout converter and operate
relative to opened directories instead of resolving absolute paths with
fsRootPath. This avoids redundant path resolution before os.Root-based
operations while reducing the TOCTOU surface.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Pass os.Root into handleTarTypeBlockCharFifo and perform the
filesystem operation relative to the opened parent directory instead
of constructing an absolute path.

This removes an fsRootPath call, avoids an extra pathname resolution,
and keeps the operation within os.Root.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
@thaJeztah

Copy link
Copy Markdown
Member

I'll bring this one in; we may have some follow-ups, but this has had enough eyes by now 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants